package hudson.plugins.pipelinesinktrigger; import hudson.Extension; import hudson.model.Item; import hudson.model.Result; import hudson.model.TopLevelItem; import hudson.model.AbstractProject; import hudson.model.Cause; import hudson.model.Hudson; import hudson.model.Project; import hudson.model.Run; import hudson.model.listeners.ItemListener; import hudson.triggers.Trigger; import hudson.triggers.TriggerDescriptor; import hudson.triggers.TimerTrigger; import hudson.util.FormValidation; import java.io.File; import java.io.IOException; import java.util.ArrayList; import java.util.Arrays; import java.util.Collections; import java.util.HashSet; import java.util.Iterator; import java.util.List; import java.util.Set; import java.util.Stack; import java.util.logging.Level; import java.util.logging.Logger; import javax.servlet.ServletException; import org.antlr.runtime.RecognitionException; import org.apache.commons.codec.digest.DigestUtils; import org.apache.commons.lang3.StringUtils; import org.jgrapht.DirectedGraph; import org.jgrapht.EdgeFactory; import org.jgrapht.alg.CycleDetector; import org.jgrapht.graph.DefaultDirectedGraph; import org.jgrapht.traverse.DepthFirstIterator; import org.kohsuke.stapler.DataBoundConstructor; import org.kohsuke.stapler.QueryParameter; import com.google.common.base.Charsets; import com.google.common.base.Joiner; import com.google.common.base.Strings; import com.google.common.collect.Lists; import com.google.common.collect.Sets; import com.google.common.io.Files; /** * {@link Trigger} primarily used for periodically scheduling a build of a configured sink job if and only if the corresponding build pipeline graph * is inactive, stable, and stale: * <ul> * <li><b>Inactive:</b> None of the jobs that make up the nodes of the build pipeline graph are currently running, or scheduled in the build queue.</li> * <li><b>Stable:</b> The last build (if present) for each job that make up the nodes of the build pipeline graph were successful. This rule can be relaxed * by selecting the <b>Ignore non-successful upstream dependency builds</b> option (not recommended).</li> * <li><b>Stale:</b> The last build of the sink job was prior to any of the build jobs that make up the nodes of the build pipeline graph.</li> * </ul> * * <p>All rules must comply in order for a build of the sink job to be scheduled.</p> */ public class BuildGraphPipelineSinkTrigger extends Trigger<AbstractProject<?,?>> { private static final Logger LOGGER = Logger.getLogger(BuildGraphPipelineSinkTrigger.class.getName()); private static final String MARKER = Strings.repeat("=", 100); private static final String CONTEXT_FINGERPRINT_FILE_NM = "pipeline-context.fingerprint"; private String rootProjectName; private String sinkProjectName; private String excludedProjectNames; private final boolean ignoreNonSuccessfulUpstreamDependencyBuilds; private final boolean verbose; @DataBoundConstructor public BuildGraphPipelineSinkTrigger(String spec, String rootProjectName, String sinkProjectName, String excludedProjectNames, boolean ignoreNonSuccessfulUpstreamDependencyBuilds, boolean verbose) throws RecognitionException { super(spec); this.rootProjectName = rootProjectName; this.sinkProjectName = sinkProjectName; this.excludedProjectNames = excludedProjectNames; this.ignoreNonSuccessfulUpstreamDependencyBuilds = ignoreNonSuccessfulUpstreamDependencyBuilds; this.verbose = verbose; } public String getRootProjectName() { return rootProjectName; } public String getSinkProjectName() { return sinkProjectName; } public String getExcludedProjectNames() { return excludedProjectNames; } public boolean isIgnoreNonSuccessfulUpstreamDependencyBuilds() { return ignoreNonSuccessfulUpstreamDependencyBuilds; } public boolean isVerbose() { return verbose; } @Override public void run() { if (!Hudson.getInstance().isQuietingDown()) { LOGGER.log(Level.INFO, MARKER); LOGGER.log(Level.INFO, Messages.BuildGraphPipelineSinkTrigger_DecidingIfBuildShouldBeTriggered(this.job.getName(), sinkProjectName)); try { final TopLevelItem rootProjectItem = Hudson.getInstance().getItem(rootProjectName); if (rootProjectItem == null) { LOGGER.log(Level.INFO, Messages.BuildGraphPipelineSinkTrigger_RootProjectDoesNotExist(rootProjectName)); return; } final AbstractProject<?,?> rootProject = (AbstractProject<?,?>) rootProjectItem; if (rootProject.isDisabled()) { LOGGER.log(Level.INFO, Messages.BuildGraphPipelineSinkTrigger_RootProjectDisabled(rootProjectName)); return; } final TopLevelItem sinkProjectItem = Hudson.getInstance().getItem(sinkProjectName); if (sinkProjectItem == null) { LOGGER.log(Level.INFO, Messages.BuildGraphPipelineSinkTrigger_SinkProjectDoesNotExist(sinkProjectName)); return; } final AbstractProject<?,?> sinkProject = (AbstractProject<?,?>) sinkProjectItem; if (sinkProject.isDisabled()) { LOGGER.log(Level.INFO, Messages.BuildGraphPipelineSinkTrigger_SinkProjectDisabled(sinkProjectName)); return; } final Set<String> exclusions = new HashSet<String>(); for (String excludedPojectName : StringUtils.split(excludedProjectNames, ',')) { final TopLevelItem excludedProjectItem = Hudson.getInstance().getItem(excludedPojectName.trim()); if (excludedProjectItem == null) { LOGGER.log(Level.INFO, Messages.BuildGraphPipelineSinkTrigger_ExcludedProjectDoesNotExist(excludedPojectName.trim())); return; } exclusions.add(excludedProjectItem.getName()); } if (sinkProject.isBuilding()) { LOGGER.log(Level.INFO, Messages.BuildGraphPipelineSinkTrigger_SkippingTriggerSinceSinkProjectIsBuilding(sinkProjectName)); return; } final DirectedGraph<AbstractProject<?,?>, String> graph = constructDirectedGraph(rootProject, exclusions); final CycleDetector<AbstractProject<?,?>, String> cycleDetector = new CycleDetector<AbstractProject<?,?>, String>(graph); if (cycleDetector.detectCycles()) { LOGGER.log(Level.INFO, Messages.BuildGraphPipelineSinkTrigger_PipelineGraphContainsCycles(sinkProjectName)); return; } triggerBuildOfSinkIfNecessary(graph, rootProject, sinkProject); } catch (Exception e) { // Swallow the exception and log. LOGGER.log(Level.SEVERE, "Encountered an error during trigger execution.", e); } finally { LOGGER.log(Level.INFO, MARKER); } } } @SuppressWarnings("rawtypes") private DirectedGraph<AbstractProject<?,?>, String> constructDirectedGraph(AbstractProject<?,?> root, Set<String> exclusions) { final DirectedGraph<AbstractProject<?,?>, String> graph = new DefaultDirectedGraph<AbstractProject<?,?>, String>( new EdgeFactory<AbstractProject<?,?>, String>() { public String createEdge(AbstractProject<?,?> source, AbstractProject<?,?> target) { return String.format("'%s' --> '%s'", source.getName(), target.getName()); } }); final StringBuilder prettyPrinter = new StringBuilder(); // Used for printing the pipeline graph as an adjacency list matrix. final Stack<AbstractProject<?,?>> stack = new Stack<AbstractProject<?,?>>(); graph.addVertex(root); stack.push(root); while (!stack.isEmpty()) { final AbstractProject<?,?> p = stack.pop(); prettyPrinter.append(p.getName()); prettyPrinter.append(": {"); int index = 0; final List<AbstractProject> children = p.getDownstreamProjects(); for (AbstractProject<?,?> child : children) { if (!child.isDisabled() && !exclusions.contains(child.getName())) { graph.addVertex(child); graph.addEdge(p, child); stack.push(child); if (index > 0) { prettyPrinter.append(", "); } prettyPrinter.append(child.getName()); index++; } } prettyPrinter.append(String.format("}%n")); } if (verbose) { LOGGER.log(Level.INFO, String.format("The build pipeline graph rooted at '%s':%n%s", root.getName(), prettyPrinter.toString())); } return graph; } private void triggerBuildOfSinkIfNecessary(DirectedGraph<AbstractProject<?,?>, String> graph, AbstractProject<?,?> root, AbstractProject<?,?> sink) throws IOException { final List<String> lastBuildFingerprints = Lists.newArrayList(); final List<String> listOfNonSuccessfulUpstreamProjectBuilds = new ArrayList<String>(); final DepthFirstIterator<AbstractProject<?,?>, String> itr = new DepthFirstIterator<AbstractProject<?,?>, String>(graph, root); while (itr.hasNext()) { final AbstractProject<?,?> project = itr.next(); if (project.isBuilding() || project.isInQueue()) { LOGGER.log(Level.INFO, Messages.BuildGraphPipelineSinkTrigger_PipelineActive(sinkProjectName)); return; } final Run<?,?> lastBuild = project.getLastBuild(); if (lastBuild != null && lastBuild.getResult().isWorseThan(Result.UNSTABLE)) { listOfNonSuccessfulUpstreamProjectBuilds.add(project.getName()); } // Capture a contextual "fingerprint" (note: the fingerprint is composed of the project's full name, and last build id (if present), so // if a project is renamed during its existence, then it can impact the detection of changes between consecutive polls of this trigger). lastBuildFingerprints.add(String.format("%s(%s)", project.getFullName(), (lastBuild == null ? "" : lastBuild.getId()))); } if (!listOfNonSuccessfulUpstreamProjectBuilds.isEmpty()) { final StringBuilder sb = new StringBuilder(); for (int i = 0; i < listOfNonSuccessfulUpstreamProjectBuilds.size(); i++) { sb.append(listOfNonSuccessfulUpstreamProjectBuilds.get(i)); if (i < listOfNonSuccessfulUpstreamProjectBuilds.size() - 1) { sb.append(", "); } } if (!ignoreNonSuccessfulUpstreamDependencyBuilds) { LOGGER.log(Level.INFO, Messages.BuildGraphPipelineSinkTrigger_DetectedNonSuccessfulUpstreamDependencyBuilds(sinkProjectName, sb.toString())); return; } LOGGER.log(Level.INFO, Messages.BuildGraphPipelineSinkTrigger_IgnoringNonSuccessfulUpstreamDependencyBuilds(sb.toString())); } // Determine if a build of the sink has already been triggered due to upstream dependency build changes. final String currentFingerprint = calculateFingerprint(lastBuildFingerprints); final String prevFingerprint = readFingerprint(); // Prevent a build of the sink project from being triggered upon initial setup of the trigger job itself (i.e. the previous fingerprint // information will not exist when the first poll has been issued). Persist the initial fingerprint, and from this point onwards, any // changes in the build pipeline graph will be detected. if (prevFingerprint == null) { persistFingerprint(currentFingerprint); LOGGER.log(Level.INFO, Messages.BuildGraphPipelineSinkTrigger_NoPreviousFingerprintToCompareAgainst(sinkProjectName)); return; } if (currentFingerprint.equals(prevFingerprint)) { LOGGER.log(Level.INFO, Messages.BuildGraphPipelineSinkTrigger_NoUpstreamDependencyBuildChanges(sinkProjectName)); return; } // A change has been detected, so update the context, and schedule a build of the sink project. persistFingerprint(currentFingerprint); LOGGER.log(Level.INFO, Messages.BuildGraphPipelineSinkTrigger_DetectedUpstreamDependencyBuildChanges(sinkProjectName)); final boolean isBuildScheduled = sink.scheduleBuild(new BuildGraphPipelineSinkTriggerCause()); LOGGER.log(Level.INFO, isBuildScheduled ? hudson.tasks.Messages.BuildTrigger_Triggering(sinkProjectName) : hudson.tasks.Messages.BuildTrigger_InQueue(sinkProjectName)); } private String calculateFingerprint(List<String> lastBuildFingerprints) { Collections.sort(lastBuildFingerprints); return DigestUtils.shaHex(Joiner.on(';').join(lastBuildFingerprints)); } private String readFingerprint() throws IOException { final File file = new File(this.job.getRootDir(), CONTEXT_FINGERPRINT_FILE_NM); return file.exists() ? Files.readFirstLine(file, Charsets.UTF_8) : null; } private void persistFingerprint(String fingerprint) throws IOException { Files.write(fingerprint, new File(this.job.getRootDir(), CONTEXT_FINGERPRINT_FILE_NM), Charsets.UTF_8); } @Extension public static final class BuildGraphPipelineSinkTriggerDescriptor extends TriggerDescriptor { private final TimerTrigger.DescriptorImpl timerTriggerDescriptorDelegate = new TimerTrigger.DescriptorImpl(); public FormValidation doCheckRootProjectName(@QueryParameter String rootProjectName) throws IOException, ServletException { return validateProjectParemeter(rootProjectName); } public FormValidation doCheckSinkProjectName(@QueryParameter String sinkProjectName) throws IOException, ServletException { return validateProjectParemeter(sinkProjectName); } public FormValidation doCheckExcludedProjectNames(@QueryParameter String excludedProjectNames) throws IOException, ServletException { if (excludedProjectNames.trim().length() > 0) { for (String excludedPojectName : StringUtils.split(excludedProjectNames, ',')) { final FormValidation val = validateProjectParemeter(excludedPojectName.trim()); if (FormValidation.Kind.ERROR.equals(val.getKind())) { return val; } } } return FormValidation.ok(); } public FormValidation doCheckSpec(@QueryParameter String spec) throws IOException, ServletException { return timerTriggerDescriptorDelegate.doCheckSpec(spec); } private FormValidation validateProjectParemeter(String projectName) throws IOException, ServletException { if (projectName.trim().length() == 0) { return FormValidation.error(Messages.BuildGraphPipelineSinkTrigger_NoProjectSpecified()); } final Item item = Hudson.getInstance().getItem(projectName); if (item == null) { return FormValidation.error(Messages.BuildGraphPipelineSinkTrigger_NoSuchProject(projectName)); } if (!AbstractProject.class.isAssignableFrom(item.getClass())) { return FormValidation.error(hudson.tasks.Messages.BuildTrigger_NotBuildable(projectName)); } return FormValidation.ok(); } @Override public String getDisplayName() { return Messages.BuildGraphPipelineSinkTrigger_DisplayName(); } @Override public boolean isApplicable(Item item) { return item instanceof TopLevelItem; } } private static final class BuildGraphPipelineSinkTriggerCause extends Cause { public BuildGraphPipelineSinkTriggerCause() { super(); } @Override public String getShortDescription() { return Messages.BuildGraphPipelineSinkTrigger_CauseShortDescription(); } } /** * Called from {@link BuildGraphPipelineSinkTrigger.DefaultItemListener} when a job is renamed. * * @return {@code true} if this {@link BuildGraphPipelineSinkTrigger} is changed and needs to be saved, otherwise {@code false}. */ public boolean onJobRenamed(String oldName, String newName) { final boolean excludedProjectNamesChanged = handleRenameForExcludedProjectNames(oldName, newName); final boolean rootProjectNameChanged = handleRenameForRootProjectName(oldName, newName); final boolean sinkProjectNameChanged = handleRenameForSinkProjectName(oldName, newName); return (excludedProjectNamesChanged || rootProjectNameChanged || sinkProjectNameChanged); } private boolean handleRenameForExcludedProjectNames(String oldName, String newName) { boolean changed = false; if (StringUtils.stripToEmpty(excludedProjectNames).length() > 0) { final StringBuilder sb = new StringBuilder(); final String[] exclusions = StringUtils.split(excludedProjectNames, ','); for (int i = 0; i < exclusions.length; i++) { if (exclusions[i].trim().equals(oldName)) { sb.append(newName); changed = true; } else { sb.append(exclusions[i].trim()); } if (i < exclusions.length - 1) { sb.append(','); } } excludedProjectNames = sb.toString(); } return changed; } private boolean handleRenameForRootProjectName(String oldName, String newName) { if (rootProjectName.equals(oldName)) { rootProjectName = newName; return true; } return false; } private boolean handleRenameForSinkProjectName(String oldName, String newName) { if (sinkProjectName.equals(oldName)) { sinkProjectName = newName; return true; } return false; } /** * Called from {@link BuildGraphPipelineSinkTrigger.DefaultItemListener} when a job is deleted. Any changes due to the * deletion of the specified job are only limited to the excluded project names field. * * @return {@code true} if this {@link BuildGraphPipelineSinkTrigger} is changed and needs to be saved, otherwise {@code false}. */ public boolean onJobDeleted(String nameOfDeletedJob) { boolean changed = false; if (StringUtils.stripToEmpty(excludedProjectNames).length() > 0) { final Set<String> setOfExclusions = Sets.newLinkedHashSet(); setOfExclusions.addAll(Arrays.asList(StringUtils.split(excludedProjectNames, ','))); final Iterator<String> itr = setOfExclusions.iterator(); while (itr.hasNext()) { final String exclusion = itr.next(); if (exclusion.trim().equals(nameOfDeletedJob)) { itr.remove(); changed = true; } } if (changed) { final StringBuilder sb = new StringBuilder(); int i = 0; for (String exclusion : setOfExclusions) { sb.append(exclusion.trim()); if (i < setOfExclusions.size() - 1) { sb.append(','); } i++; } excludedProjectNames = sb.toString(); } } return changed; } @Extension public static final class DefaultItemListener extends ItemListener { @Override public void onDeleted(Item item) { for (Project<?, ?> p : Hudson.getInstance().getProjects()) { final BuildGraphPipelineSinkTrigger trigger = p.getTrigger(BuildGraphPipelineSinkTrigger.class); if (trigger != null) { if (trigger.onJobDeleted(item.getName())) { try { p.save(); } catch (IOException e) { LOGGER.log(Level.WARNING, String.format("Failed to persist project setting during deletion of %s", item.getName()), e); } } } } } @Override public void onRenamed(Item item, String oldName, String newName) { for (Project<?, ?> p : Hudson.getInstance().getProjects()) { final BuildGraphPipelineSinkTrigger trigger = p.getTrigger(BuildGraphPipelineSinkTrigger.class); if (trigger != null) { if (trigger.onJobRenamed(oldName, newName)) { try { p.save(); } catch (IOException e) { LOGGER.log(Level.WARNING, String.format("Failed to persist project setting during rename from %s to %s", oldName, newName), e); } } } } } } }